Skip to content

fix: share one HTTP client per provider and size its connection pool - #1021

Merged
alexluong merged 3 commits into
mainfrom
fix/shared-http-client
Aug 10, 2026
Merged

fix: share one HTTP client per provider and size its connection pool#1021
alexluong merged 3 commits into
mainfrom
fix/shared-http-client

Conversation

@alexluong

@alexluong alexluong commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #1017.

Outpost built a separate http.Client for every destination and never configured pooling on it, so each inherited Go's default of two idle connections. Above two concurrent deliveries to a destination, reuse collapsed to roughly one new connection per delivery.

What changed

One client per provider. Nothing in the client configuration varies per destination — user agent, proxy settings and the transport wrapper all come from provider-level options fixed at registration — so the client moves from CreatePublisher to the provider constructor (destwebhook, destwebhookstandard, desthookdeck). This also means publisher-cache eviction no longer throws away the connection pool.

The rule the code now documents: only connection-level concerns justify a separate client. If per-destination proxy or client certificates are needed later, transports should be keyed by configuration and shared within a key.

Derived pool sizing (internal/destregistry/connpool.go) — sane defaults scaling with DELIVERY_MAX_CONCURRENCY (call it C):

value
per-host (depth) max(C, 2) — 2 is Go's default, so C=1 never sizes below stock
total (breadth) clamp(32 × C, 512, max(4096, C))

MaxIdleConns caps only parked (idle) connections, never active ones — exceeding it causes connection churn, not errors. That makes the constants safe to reason about as reuse-rate tuning:

  • 32 × C ≈ how many distinct destinations a worker revisits within the 90s IdleConnTimeout at ~3s per delivery — slow destinations are where reuse matters most.
  • Floor 512 covers low-concurrency fanout: concurrency bounds simultaneous requests, not distinct hosts touched over time. C=1 at ~100ms per delivery still cycles ~900 destinations per idle window.
  • Cap 4096 bounds the parked-FD/memory cost where the reuse hit rate decays — but the cap rises to C, so it never undersizes the pool below the concurrency level.

The hookdeck provider talks to one host, so it gets a depth-only pool.

Why not derive from RLIMIT_NOFILE (an earlier revision did): the limit is per-process and unreliable in containers — the RLIM_INFINITY quirk meant the most generous environments derived the smallest pools — and mq-provider publishers hold per-destination FDs the derivation never accounted for. Since the limit only governs idle connections, tying it to a hard resource ceiling was solving the wrong problem.

No new configuration. These are defaults that scale with the one knob operators already set for delivery throughput. If a workload needs different sizing, an explicit config knob can be exposed later — adding one is backward-compatible; removing one isn't. Meanwhile:

  • resolved values logged at startup (delivery_max_idle_conns, delivery_max_idle_conns_per_host)
  • new outpost.delivery_connections counter, dimensioned by type and reused — the signal that the ceiling is binding

Tests

internal/destregistry/httpclient_pool_test.go counts TCP connections opened at an httptest server via ConnState, swept across concurrency levels against both fast and slow destinations — the two regimes fail differently (latency vs. ephemeral ports). Connections opened track the concurrency level, not the request count.

A control case runs the identical workload through a stock-default client so the assertion can't pass trivially: at concurrency 32, stock opens ~120 connections for 320 requests, sized opens 32.

destwebhook_connpool_test.go covers the provider half — 8 publishers, 40 requests, still bounded by concurrency.

Behavior changes worth flagging

  • An invalid DESTINATIONS_WEBHOOK_PROXY_URL now fails at startup instead of on first publish.
  • Idle connections are held on the receiver for up to 90 seconds after a delivery. Those connections were opened anyway; pooling changes whether they're held afterward.
  • The pool is shared across tenants. Connections are per-host so there's no correctness concern, but a busy tenant can evict a quiet tenant's idle connections; the per-host limit bounds the blast radius.

Verification

go test -short ./... clean. Pool tests run at -count=15 for flake.

🤖 Generated with Claude Code

Outpost built a separate http.Client for every destination and never
configured connection pooling on it, so each one inherited Go's default of
two idle connections. Above two concurrent deliveries to a destination,
reuse collapsed to roughly one new connection per delivery — latency
against fast destinations, TIME_WAIT accumulation against slow ones.

Nothing in the client configuration varies per destination: user agent,
proxy settings and the transport wrapper all come from provider-level
options fixed at registration. So the client is now built once in the
provider constructor, which makes the per-host idle limit meaningful and
gives a real ceiling on total idle connections.

Pool sizing is derived rather than configured:

- per-host (depth) from DELIVERY_MAX_CONCURRENCY, floored at Go's default
- total (breadth) from RLIMIT_NOFILE — a quarter of the soft limit,
  floored at 100 and capped at 4096. Fan-out is the normal shape for this
  product, so Go's default of 100 is the wrong thing to inherit; a
  deployment with 600 low-rate destinations needs breadth it can't derive
  from concurrency.

The hookdeck provider talks to one host, so it gets a depth-only pool.

Deliberately no new env vars: the correct total depends on the active
destination count and the host's FD limit, which an operator would have
to know both of to set sensibly. Instead the resolved values are logged
at startup alongside the FD limit they came from, and a new
outpost.delivery_connections metric reports connections opened against
connections reused — the signal that the ceiling is binding.

Reading RLIMIT_NOFILE is Unix-only; Windows falls back to an assumed 1024.

Closes #1017

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alexluong

alexluong commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Added #1022 in d4d1175 — pulled back out. #1025 rewrites the same signature formatter constructors this touched, so keeping the two together meant one PR blocking the other on a merge conflict for no reason. #1022 will land separately, based on #1025.

This PR is #1017 only: connection pooling.

@alexluong
alexluong force-pushed the fix/shared-http-client branch from d4d1175 to 9a8a215 Compare August 7, 2026 07:13
Drop the FD-limit derivation for the fan-out pool total. RLIMIT_NOFILE
is per-process and unreliable in containers (the RLIM_INFINITY fallback
gave the most generous environments the smallest pools), the unix probe
did not even compile on FreeBSD (int64 Rlimit.Cur), and mq-provider
publishers hold per-destination FDs the derivation never accounted for.
MaxIdleConns caps only parked connections — exceeding it costs churn,
not errors — so a hard resource ceiling was the wrong basis anyway.

New sizing, from DELIVERY_MAX_CONCURRENCY (C) alone:
total = clamp(32*C, 512, max(4096, C)); per-host = max(C, 2) as before.

Also: drop the FD field from the startup log, loosen the stock-defaults
comparison test to a strict inequality (pinned 2x ratio was flaky), and
build desthookdeck.NewPublisher's fallback client through NewHTTPClient
so it shares the pooled-transport path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep both sides in destwebhookstandard: the shared HTTP client from this
branch and the hoisted spec-fixed formatters from main (#1025).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexluong
alexluong merged commit d0b685d into main Aug 10, 2026
3 of 4 checks passed
@alexluong
alexluong deleted the fix/shared-http-client branch August 10, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Webhook deliveries open a new connection for nearly every request

2 participants